Syncing Obsidian with Quarto
🌱 Building a Digital Garden with Obsidian, Quarto, and GitHub
I keep a lot of notes. Ideas, half-written thoughts, small experiments, things I want to come back to later. For a long time, these lived only in my local Obsidian vault — private, messy, and useful only to me.
At the same time, I wanted a public-facing space that:
- didn’t force every post to be “finished”
- allowed ideas to grow over time
- felt closer to a notebook than a blog
That’s how I ended up building a digital garden using:
- Obsidian for writing
- Quarto for publishing
- GitHub Pages for hosting
- a small Python script to glue everything together
This post walks through that setup step by step.
✍️ Writing in Obsidian
I use Obsidian as my primary writing tool because:
- Markdown is simple and portable
- Notes are just files on disk
- Linking ideas is effortless
My folder structure looks roughly like this:
Digital Garden/
├── Digital Garden.md
├── Obsidian Tips and Tricks/
│ └── Syncing Obsidian with Quarto.md
├── Quarto/
│ └── Publishing with GitHub Pages.md
A few conventions I follow:
- Folder names = categories
- File name = page title
📦 What is Quarto?
Quarto is a publishing system built on Pandoc. It lets you write Markdown and publish it as blogs, documentation, academic papers, or personal websites.
Why I chose Quarto:
- Markdown-first
- Native support for drafts
- Excellent GitHub Pages integration
- Clean defaults, minimal configuration
🛠 Setting Up a Quarto Website
Project structure:
my-garden/
├── _quarto.yml
├── index.qmd
├── posts/
└── styles.css
🚀 Publishing with GitHub Pages
Quarto builds the site and deploys it to GitHub Pages automatically.
🔄 Syncing Obsidian → Quarto
To avoid manual copying, I use a small Python script to sync notes from Obsidian into Quarto posts.
Rules:
- Every
.mdfile becomes a Quarto post - Folder names become categories
- File name becomes the title
- Everything is published as
draft: true - Changes are detected via a content hash
🧩 The Sync Script
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple, Union
# We embed metadata at the bottom of generated files so we can detect changes.
SYNC_MARKER_PREFIX = "<!-- obsidian-sync:"
SYNC_MARKER_RE = re.compile(r"<!--\s*obsidian-sync:\s*(\{.*?\})\s*-->")
# Very small frontmatter detector
FM_START = "---"
FM_END = "---"
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def slugify(s: str) -> str:
s = s.strip().lower()
s = s.replace("&", " and ")
s = s.replace("’", "'").replace("'", "")
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-{2,}", "-", s).strip("-")
return s or "post"
def escape_yaml_double_quotes(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
def yaml_list(items: List[str]) -> str:
quoted = [f'"{escape_yaml_double_quotes(x)}"' for x in items]
return "[" + ", ".join(quoted) + "]"
def guess_date_from_mtime(path: Path) -> str:
dt = datetime.fromtimestamp(path.stat().st_mtime)
return dt.strftime("%Y-%m-%d")
def read_existing_sync_meta(qmd_text: str) -> Optional[dict]:
"""
Reads our sync marker like:
<!-- obsidian-sync:{"source":"...","hash":"..."} -->
Returns dict or None.
"""
m = SYNC_MARKER_RE.search(qmd_text)
if not m:
return None
raw = m.group(1)
meta = {}
for k, v in re.findall(r'"([^"]+)"\s*:\s*"([^"]*)"', raw):
meta[k] = v
return meta or None
def is_hidden_or_obsidian_internal(path: Path) -> bool:
# Skip hidden dirs/files and .obsidian internals
for part in path.parts:
if part.startswith("."):
return True
return False
def iter_md_files(root: Path) -> Iterable[Path]:
for p in root.rglob("*.md"):
if is_hidden_or_obsidian_internal(p):
continue
yield p
def split_frontmatter(text: str) -> Tuple[Optional[str], str]:
"""
Returns (frontmatter_text_or_None, body_text).
Assumes frontmatter is at the very top of the file and delimited by --- lines.
"""
if not text.startswith(FM_START + "\n"):
return None, text
# Find the next line that is exactly '---'
end_idx = text.find("\n" + FM_END + "\n", len(FM_START) + 1)
if end_idx == -1:
# Malformed frontmatter; treat as no frontmatter
return None, text
fm = text[len(FM_START) + 1 : end_idx] # between --- and --- (no surrounding newlines)
body = text[end_idx + len("\n" + FM_END + "\n") :]
return fm, body
def parse_scalar(value: str) -> Union[str, bool]:
v = value.strip()
if v.lower() == "true":
return True
if v.lower() == "false":
return False
return v
def parse_frontmatter_minimal(fm_text: str) -> Dict[str, object]:
"""
Minimal YAML-ish parser supporting:
key: value
key:
- item
- item
for your use case (title/date/draft/digital_garden/Additional Tags).
Not a full YAML parser; intentionally small & predictable.
"""
data: Dict[str, object] = {}
lines = fm_text.splitlines()
i = 0
while i < len(lines):
line = lines[i].rstrip()
i += 1
if not line.strip():
continue
# key: value OR key:
m = re.match(r"^([A-Za-z0-9_ -]+):\s*(.*)$", line)
if not m:
continue
key = m.group(1).strip()
rest = m.group(2).strip()
if rest != "":
# scalar
data[key] = parse_scalar(rest)
continue
# key: (maybe a list below)
items: List[str] = []
while i < len(lines):
nxt = lines[i].rstrip()
# list items are indented + start with "-"
if re.match(r"^\s+-\s+.+$", nxt):
item = re.sub(r"^\s+-\s+", "", nxt).strip()
items.append(item)
i += 1
continue
# stop when indentation/list ends
break
data[key] = items
return data
@dataclass
class Note:
source_path: Path
title: str
slug: str
date: str
draft: bool
categories: List[str]
digital_garden: bool
body_md: str
source_hash: str
def build_note(source_path: Path) -> Note:
raw = source_path.read_text(encoding="utf-8")
fm_text, body = split_frontmatter(raw)
fm: Dict[str, object] = parse_frontmatter_minimal(fm_text) if fm_text else {}
title = str(fm.get("title") or source_path.stem)
slug = slugify(title)
# date: prefer FM, else mtime
date_val = fm.get("date")
if isinstance(date_val, str) and date_val.strip():
post_date = date_val.strip()
else:
post_date = guess_date_from_mtime(source_path)
# draft: default true (matches your workflow)
draft_val = fm.get("draft")
draft = bool(draft_val) if isinstance(draft_val, bool) else True
# digital_garden: default false (only sync true)
dg_val = fm.get("digital_garden")
digital_garden = bool(dg_val) if isinstance(dg_val, bool) else False
# Additional Tags -> categories
tags = fm.get("Additional Tags")
categories = [str(x) for x in tags] if isinstance(tags, list) else []
# Hash includes path + full raw content (frontmatter + body)
content_for_hash = f"{source_path.as_posix()}\n---\n{raw}"
src_hash = sha256_text(content_for_hash)
# Keep the body as-is (including headings). We already removed frontmatter by splitting.
body_md = body.lstrip("\n")
return Note(
source_path=source_path,
title=title,
slug=slug,
date=post_date,
draft=draft,
categories=categories,
digital_garden=digital_garden,
body_md=body_md,
source_hash=src_hash,
)
def render_qmd(note: Note, author: Optional[str]) -> str:
sync_meta = (
f'{SYNC_MARKER_PREFIX}{{"source":"{note.source_path.as_posix()}",'
f'"hash":"{note.source_hash}"}} -->'
)
yaml_lines: List[str] = [
"---",
f'title: "{escape_yaml_double_quotes(note.title)}"',
]
if author:
yaml_lines.append(f'author: "{escape_yaml_double_quotes(author)}"')
yaml_lines += [
f'date: "{note.date}"',
f"categories: {yaml_list(note.categories)}",
f"draft: {'true' if note.draft else 'false'}",
"---",
"",
]
return "\n".join(yaml_lines) + note.body_md.rstrip() + "\n\n" + sync_meta + "\n"
def should_update(dest_path: Path, new_hash: str) -> bool:
if not dest_path.exists():
return True
existing = dest_path.read_text(encoding="utf-8")
meta = read_existing_sync_meta(existing)
if not meta:
return True
return meta.get("hash") != new_hash
def main() -> int:
ap = argparse.ArgumentParser(
description="One-way sync: Obsidian markdown notes (digital_garden: true) -> Quarto posts/<slug>/index.qmd"
)
ap.add_argument("--obsidian-root", required=True, help="Path to your Obsidian vault (or subfolder)")
ap.add_argument("--quarto-root", required=True, help="Path to your Quarto project root (contains posts/)")
ap.add_argument("--posts-dir", default="Digital Garden", help="Posts directory under Quarto root (default: posts)")
ap.add_argument("--author", default=None, help="Author name to include in YAML (optional)")
ap.add_argument("--dry-run", action="store_true", help="Show changes without writing files")
args = ap.parse_args()
obsidian_root = Path(args.obsidian_root).expanduser().resolve()
quarto_root = Path(args.quarto_root).expanduser().resolve()
posts_root = (quarto_root / args.posts_dir).resolve()
if not obsidian_root.exists():
raise SystemExit(f"Obsidian root not found: {obsidian_root}")
if not quarto_root.exists():
raise SystemExit(f"Quarto root not found: {quarto_root}")
posts_root.mkdir(parents=True, exist_ok=True)
created = updated = skipped = ignored = 0
for md_path in iter_md_files(obsidian_root):
note = build_note(md_path)
# Only sync notes explicitly marked for publishing
if not note.digital_garden:
ignored += 1
continue
# Preserve Obsidian folder structure under posts/
rel_dir = note.source_path.parent.relative_to(obsidian_root) # e.g. "Obsidian tip and tricks/Subfolder"
out_dir = posts_root / rel_dir
out_file = out_dir / f"{note.slug}.qmd"
qmd_text = render_qmd(note, author=args.author)
if should_update(out_file, note.source_hash):
action = "CREATE" if not out_file.exists() else "UPDATE"
print(f"{action}: {out_file} <= {md_path}")
if not args.dry_run:
out_dir.mkdir(parents=True, exist_ok=True)
out_file.write_text(qmd_text, encoding="utf-8")
created += 1 if action == "CREATE" else 0
updated += 1 if action == "UPDATE" else 0
else:
print(f"SKIP (no change): {out_file}")
skipped += 1
print(f"\nDone. Created: {created}, Updated: {updated}, Skipped: {skipped}, Ignored (digital_garden!=true): {ignored}")
return 0
if __name__ == "__main__":
raise SystemExit(main())How to run this?
python3 sync_obsidian_to_quarto.py \
--obsidian-root "path/to/obsidian_folder" \
--quarto-root "path/to/quarto_project/root" \
--author "Your Name"